"""实现用户登录注册功能"""

import logging
import time
import unicodedata
from collections import defaultdict
from datetime import datetime, timedelta

import bcrypt
import jwt
import pymysql
import tornado.escape
import tornado.web
from tornado.ioloop import IOLoop

from config import (
    REGISTRATION_TOKEN_COOKIE_NAME, TOKEN_NAME, JWT_EXPIRES, DOMAIN_NAME,
    PASSWORD_RESET_TOKEN_COOKIE_NAME,
)
from utils import validate_email, generate_random_string
# Local import
from .base import BaseHandler

USERNAME_SIZE = 32
EMAIL_SIZE = 64
BASE_URL = 'http://localhost:6000'


class SendActivationMailMixin:
    """发送激活邮件混合类"""

    async def send_activation_email(self, credentials):
        token = generate_random_string()
        try:
            # 设置redis中存储的键，并放到cookie中
            key = f'{credentials["username"]}_{time.time()}_register_confirm_token'
            link = tornado.escape.linkify(BASE_URL + self.reverse_url('auth_register_confirm', token))
            msg = f'''
{credentials['username']}, 欢迎你成为{DOMAIN_NAME}注册会员<br/>
请点击下面连接激活你的账户: <br/>
{link} <br/>

--{DOMAIN_NAME}团队
'''
            await self.send_mail(
                credentials=credentials,
                cookie_name=REGISTRATION_TOKEN_COOKIE_NAME,
                key=key,
                msg=msg,
                token=token
            )
        except Exception as e:
            logging.error(e)
            self.write_error(400, status=1, errors=['邮件发送失败，请重试'])
        else:
            self.write({'status': 0, 'detail': f'请查看邮箱{credentials["email"]}，激活您的账户'})


class RegisterHandler(SendActivationMailMixin, BaseHandler):
    def validate_data(self, data):
        errors = defaultdict(list)  # {field: [list of error messages]}
        for field, val in data.items():
            if val == '':
                errors[field].append(f'{field}不能为空')

        if len(data['username']) > USERNAME_SIZE:
            errors['username'].append('用户名太长')

        if not validate_email(data['email']):
            errors['email'].append('无效邮箱地址')

        if len(data['email']) > EMAIL_SIZE:
            errors['email'].append('邮箱太长')

        if data['password1'] != data['password2']:
            errors['password2'].append('确认密码不匹配，请重新输入')

        return errors

    async def post(self):
        fields = ('username', 'email', 'password1', 'password2')
        # 不需要额外的字段
        credentials = {field: self.request_data.get(field, '').strip() for field in fields}

        # 后端数据校验
        errors = self.validate_data(credentials)
        if errors:
            self.write_error(400, status=1, errors=dict(errors))  # defaultdict -> dict
            return

        # 密码哈希-耗时操作
        hashed_password = await IOLoop.current().run_in_executor(
            None,  # 默认使用线程池执行器(ThreadPoolExecutor)
            bcrypt.hashpw,
            tornado.escape.utf8(credentials['password1']),
            bcrypt.gensalt()
        )

        # 往数据库中插入数据
        try:
            await self.execute(
                'INSERT INTO user (username, email, password) VALUES (%s, %s, %s)',
                unicodedata.normalize('NFKC', credentials['username']),
                credentials['email'],  # email需要normalize？
                tornado.escape.to_unicode(hashed_password)
            )
            # 发送激活邮件
            await self.send_activation_email(credentials)

        # 因为用户名和邮箱是唯一的，会出现唯一性错误
        except pymysql.err.IntegrityError as e:
            error_messages = []
            if 'user.username' in str(e):
                error_messages.append(f'用户名{credentials["username"]}已经注册,请使用其他用户名')
            else:
                error_messages.append(f'邮箱{credentials["email"]}已经注册,请使用其他邮箱')

            self.write_error(400, status=1, errors=error_messages)

        # 这里不太容易出错，因为前面已经做数据校验
        except pymysql.err.DataError:
            self.write_error(404)

        # 未知错误!!  # 可以捕获到mysql或者redis突然宕机
        except Exception as e:
            logging.error(f'<{self.__class__.__name__}>: {str(e)}')
            self.write_error(404)


class RegisterConfirmHandler(BaseHandler):
    async def get(self, token):
        key = self.get_secure_cookie(REGISTRATION_TOKEN_COOKIE_NAME)
        if not key:
            return
        key = key.decode()
        # 这地方不会报错！cookie设置的时候使用的是固定格式: 用户名_时间戳_用途描述_token
        username = key.split('_')[0]

        # 如果用户已经激活,直接返回提示信息
        row = await self.fetchone(
            'SELECT id FROM user WHERE username = %s and is_active = %s LIMIT 1',
            username, 1)
        if row:
            self.write({'status': 0, 'detail': '帐号已经激活，请登录'})
            return

        try:
            with await self.redis_pool as redis_conn:
                val = await redis_conn.execute('get', key)
        except Exception as e:
            logging.error(e)
            self.write_error(404, status=1, errors=['页面未找到'])
        else:
            if not val or token != val:
                self.write_error(400, status=1, errors=['无效链接，重新发送激活链接'])
            else:
                # 激活帐号
                try:
                    await self.execute('UPDATE user SET is_active = %s WHERE username = %s', 1, username)
                except Exception as e:
                    logging.error(f'帐号激活失败: {e}')
                    self.write_error(400, status=1, errors=['帐号激活失败，请重新发送激活链接'])
                else:
                    self.write({'status': 0, 'detail': '帐号激活成功，请登录'})  # 提供登录链接


# 重新发送激活码
class SendActivationEmailHandler(SendActivationMailMixin, BaseHandler):
    async def post(self):
        email = self.request_data.get('email')
        if not email:
            self.write_error(400, status=0, errors=['邮箱不能为空'])
            return

        # 该邮箱还没有注册且未激活
        try:
            # 发送邮件的时候用到用户名
            # 如果邮箱不存在返回None,反之返回{'is_active': ..., 'username': ...}字典对象
            user = await self.fetchone('SELECT is_active, username FROM user WHERE email = %s limit 1', email)
            if not user:
                # status给前端用
                self.write_error(400, status=1, errors=['该邮箱还没有注册，请先注册'])
                return

            if user['is_active']:
                self.write_error(400, status=1, errors=['该邮箱已经激活，请登录'])
            else:
                # 已经注册且未激活，发送激活邮箱提醒用户查看
                await self.send_activation_email({'email': email, 'username': user['username']})

        except Exception as e:
            logging.error(e)
            self.write_error(404, status=1, errors=['页面未找到'])


class LoginHandler(BaseHandler):
    async def authenticate(self, credentials):
        """
        用户认证

        判断用户是否存在，如果不存在返回错误信息
        如果密码错误也返回错误信息
        如果没有错误则更新用户最后登录时间，以及生成JWT返回给前端，并存放在浏览器localStorage中
        :param credentials: {'email': '...', 'password': '...'}
        """
        # 这里email和password键一定存在且值不为空，因为在post方法中先调用validate方法进行了数据校验
        email = credentials['email']
        password = credentials['password']

        user = await self.fetchone('SELECT username, password, is_active FROM user WHERE email = %s limit 1', email)

        if not user:
            self.write_error(400, status=1, errors=f'用户[{email}]不存在')
            return

        # 如果用户还没有激活，防止用户登录，返回错误信息并提醒用户查看邮箱激活账号
        if not user['is_active']:
            self.write_error(400, status=1, errors='抱歉您的账号还没有激活，请查看您的邮箱重新激活')
            return

        # 校验密码
        hashed_password = await tornado.ioloop.IOLoop.current().run_in_executor(
            None, bcrypt.hashpw, tornado.escape.utf8(password), tornado.escape.utf8(user['password'])
        )

        if tornado.escape.to_unicode(hashed_password) != user['password']:
            self.write_error(400, status=1, errors='密码不正确，请重新输入')
            return

        # 更新最后登录时间(last_login)
        try:
            await self.execute('UPDATE user SET last_login = %s', datetime.now())
        except Exception:
            pass  # 如果更新失败什么也不做，让它继续为空

        payload = {
            'exp': datetime.utcnow() + timedelta(minutes=JWT_EXPIRES),  # 过期时间
            'iat': datetime.utcnow(),  # 签发时间
            'iss': 'tornado_auth:login',  # 签发方
            'data': {  # 真正载荷
                'username': user['username'],
                'login_time': int(time.time())
            }
        }

        try:
            token = jwt.encode(
                payload,
                self.settings['cookie_secret'],  # Application中必须设置cookie_secret
                algorithm='HS256'
            )
        except Exception as e:  # 未知错误
            logging.error(e)
            self.write_error(500, status=1, errors=['服务器错误'])
        else:
            return token

    def validate(self, credentials):
        errors = []
        if credentials.get('email') is None:
            errors.append('邮箱不能为空')
        if credentials.get('password') is None:
            errors.append('密码不能为空')
        return errors

    async def post(self):
        credentials = {field: self.request_data.get(field) for field in ('email', 'password')}
        errors = self.validate(credentials)
        if errors:
            self.write_error(400, status=1, errors=errors)
            return

        token = await self.authenticate(credentials)
        if token:
            self.write({TOKEN_NAME: token.decode()})


# 退出登录状态，只需要在前端的localStorage清除掉JWT就可以了???
class LogoutHandler(BaseHandler):
    def get(self):
        pass


class PasswordResetHandler(BaseHandler):
    """
    用户忘记密码的时候才需要重置密码

    cookie中存放pass_rest_token_key -> key (redis中的键)
    redis中存储key -> 随机字符串(放到激活链接中)

    """

    async def send_password_reset_mail(self, credentials):
        try:
            token = generate_random_string()
            # 设置redis中存储的键，并放到cookie中
            key = f'{credentials["username"]}_{time.time()}_password_reset_token'
            link = tornado.escape.linkify(BASE_URL + self.reverse_url('auth_password_reset_confirm', token))
            msg = f'''
您好！， {credentials['username']}, 请点击下面链接重置您的密码: <br/>
{link} <br/>
--{DOMAIN_NAME}团队
'''
            await self.send_mail(
                credentials=credentials,
                cookie_name=PASSWORD_RESET_TOKEN_COOKIE_NAME,
                key=key,
                msg=msg,
                token=token
            )
        except Exception as e:
            logging.error(e)
            self.write_error(400, status=1, errors=['邮件发送失败，请重试'])
        else:
            self.write({'status': 0, 'detail': f'请查看邮箱{credentials["email"]}，点击链接重置您的密码'})

    async def post(self):
        email = self.request_data.get('email')
        is_valid = validate_email(email)
        if not is_valid:
            self.write_error(400, status=1, errors=['邮箱不能为空'])
            return

        user = await self.fetchone('SELECT is_active, username FROM user WHERE email = %s', email)

        if not user:
            self.write_error(400, status=1, errors=[f'用户{email}不存在, 请先注册'])
            return

        if not user['is_active']:  # 用户还没激活
            self.write_error(400, status=1, errors=['该用户还没有激活，请查看你的邮件点击激活你的账号'])
            return

        # 发送邮件
        # TODO: 不能一直发，要用户等待或者隔及分钟才能再发一次
        await self.send_password_reset_mail({'email': email, 'username': user['username']})


class PasswordResetConfirmHandler(BaseHandler):
    """
    从cookie中根据cookie名字`pass_reset_token_key`获取redis中的键
    如: wang_1606915284.8425741_password_reset_token：
    再通过键获取cookie中的Token值,和用户确认密码链接URL中的Token比对
    如果一致，重置密码，不一致返回错误信息。
    """

    def validate_password(self, password1, password2):
        errors = defaultdict(list)
        if not password1:
            errors['password1'].append('密码不能为空')

        if not password2:
            errors['password2'].append('确认密码不能为空')

        if password1 != password2:
            errors['password2'].append('密码不匹配，请重新输入')

        return errors

    async def post(self, token):
        password1 = self.request_data.get('password1')
        password2 = self.request_data.get('password2')

        errors = self.validate_password(password1, password2)
        if errors:
            self.write_error(400, status=1, errors=errors)
            return

        # 获取用户名和redis中的键
        key = self.get_secure_cookie(PASSWORD_RESET_TOKEN_COOKIE_NAME)
        if not key:
            return
        key = key.decode()

        username = key.split('_')[0]

        try:
            with await self.redis_pool as redis_conn:
                val = await redis_conn.execute('get', key)
        except Exception as e:
            logging.error(e)
            self.write_error(404, status=1, errors=['页面未找到'])
        else:
            if not val or token != val:
                self.write_error(400, status=1, errors=['无效链接,请重新发送重置链接'])
            else:
                # TODO:哈希密码时会不会出错
                # 生成加密密码
                hashed_password = await tornado.ioloop.IOLoop.current().run_in_executor(
                    None,  # 默认使用ThreadPoolExecutor
                    bcrypt.hashpw,
                    tornado.escape.utf8(password1),
                    bcrypt.gensalt(),
                )

                # 更新密码
                try:
                    await self.execute(
                        'UPDATE user SET password = %s WHERE username = %s',
                        hashed_password,
                        username
                    )
                except Exception as e:
                    logging.error(e)
                    self.write_error(400, status=1, errors=['密码重置失败，请稍后再试'])
                else:
                    self.write({'status': 0, 'detail': '密码重置成功，请登录'})  # 提供登录链接


# 用户需要登录才能更改密码,用户如果没有登录抛403异常,抛异常会调用write_error方法
class PasswordChangeHandler(BaseHandler):
    @tornado.web.authenticated  # 调用get_current_user()方法
    async def post(self):
        # dict: field_name: user_input_value
        # 不需要确认密码??
        fields = {field: self.request_data.get(field) for field in ('oldpassword', 'password')}

        user = self.current_user  # {'is_active': ..., 'username': ...}
        if user is not None:
            username = user['username']   # 一定会有数据不需要使用get方法

        # 更加token中payload的用户名，从数据库中获取密码与用户输入的密码比对,如果不匹配返回错误信息
        # 反之，更改密码
        try:
            row = await self.fetchone(
                'SELECT password FROM user WHERE username=%s', username
            )
        except Exception as e:
            logging.error(e)
            self.write_error(500, status=1, errors=['服务器异常，管理员正在处理'])
        else:
            # 代码走到这,row不大可能为None,保险
            if row is None:
                self.write_error(500, status=1, errors=['服务器异常，管理员正在处理'])
                return

            # TODO: 重复代码
            # 判断旧密码是否正确
            hashed_password = await tornado.ioloop.IOLoop.current().run_in_executor(
                None, bcrypt.hashpw, tornado.escape.utf8(fields['oldpassword']),
                tornado.escape.utf8(row['password'])
            )
            if tornado.escape.to_unicode(hashed_password) != row['password']:
                self.write_error(400, status=1, errors=['旧密码输入不正确，请重新输入'])
                return

            # TODO: 重复代码
            # 生成加密密码
            hashed_password = await tornado.ioloop.IOLoop.current().run_in_executor(
                None,
                bcrypt.hashpw,
                tornado.escape.utf8(fields['password']),
                bcrypt.gensalt()
            )
            try:
                # 更新密码
                await self.execute(
                    'UPDATE user SET password = %s WHERE username = %s',
                    tornado.escape.to_unicode(hashed_password),
                    username,
                )
            except Exception as e:
                logging.error(e)
                self.write_error(500, status=1, errors=['服务器错误，我们管理员正在处理...'])
            else:
                self.write({'status': 0, 'detail': '密码更改成功'})
